Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/next/pages/news/edit/[id].tsx
Views: 687
/*1* This file is part of CoCalc: Copyright © 2023 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import {6Alert,7Breadcrumb,8Button,9Checkbox,10Col,11DatePicker,12Divider,13Form,14Input,15Layout,16Row,17Select,18Space,19} from "antd";20import dayjs from "dayjs";21import { GetServerSidePropsContext } from "next";22import { useRouter } from "next/router";23import { useEffect, useState } from "react";2425import { getNewsItem } from "@cocalc/database/postgres/news";26import { Icon } from "@cocalc/frontend/components/icon";27import { capitalize } from "@cocalc/util/misc";28import { slugURL } from "@cocalc/util/news";29import {30CHANNELS,31CHANNELS_DESCRIPTIONS,32Channel,33NewsItem,34} from "@cocalc/util/types/news";35import Footer from "components/landing/footer";36import Head from "components/landing/head";37import Header from "components/landing/header";38import { Paragraph, Title } from "components/misc";39import A from "components/misc/A";40import { News } from "components/news/news";41import Loading from "components/share/loading";42import apiPost from "lib/api/post";43import { MAX_WIDTH, NOT_FOUND } from "lib/config";44import { Customize, CustomizeType } from "lib/customize";45import useProfile from "lib/hooks/profile";46import { extractID } from "lib/news";47import withCustomize from "lib/with-customize";4849interface Props {50customize: CustomizeType;51news?: NewsItem;52}5354type NewsTypeForm = Omit<NewsItem, "date"> & { date: dayjs.Dayjs };5556export default function EditNews(props: Props) {57const { customize, news } = props;58const router = useRouter();5960const id = news?.id; // this is set once, and never changes61const isNew = id == null;62const { siteName } = customize;63const profile = useProfile({ noCache: true });64const isAdmin = profile?.is_admin === true;6566const [form] = Form.useForm();6768const date: dayjs.Dayjs =69typeof news?.date === "number" ? dayjs.unix(news.date) : dayjs();7071const init: NewsTypeForm =72news != null73? { ...news, tags: news.tags ?? [], date }74: {75hide: false,76title: "",77text: "",78url: "",79tags: [],80channel: "feature",81date: dayjs(),82};8384const [data, setData] = useState<NewsTypeForm>(init);8586const [error, setError] = useState<string>("");87const [saving, setSaving] = useState<boolean>(false);88const [invalid, setInvalid] = useState<boolean>(false);89const [saved, setSaved] = useState<number | null>(null);9091useEffect(() => {92form.setFieldsValue(data);9394// If we're creating a new item, set the channel from URL params (if such a param exists).95// This is used when creating a new event from the events page.96//97if (isNew) {98const { channel } = router.query;99if (100typeof channel === "string" &&101CHANNELS.includes(channel as Channel)102) {103form.setFieldValue("channel", channel);104}105}106107form.validateFields();108}, [data]);109110async function save() {111setSaving(true);112try {113// send data, but convert date field to epoch seconds114const next = { ...data, id, date: data.date.unix() };115const { channel } = data;116const ret = await apiPost("/news/edit", next);117if (ret == null || ret.id == null) {118throw Error("Problem saving news item – no id returned.");119}120if (channel === "event") {121router.push("/about/events", undefined, { scroll: false });122} else {123router.push(124slugURL({125...data,126...ret,127}),128undefined,129{ scroll: false },130);131}132// this signals to the user that the save was successful133setSaved(ret.id);134} catch (err) {135setError(err.message);136} finally {137setSaving(false);138setError("");139}140}141142function renderSaved() {143if (saving || saved == null) return;144return (145<Alert146banner147type="success"148icon={<Icon name="check" />}149message={150<>151<A href={slugURL({ ...data, id })}>Saved News id={saved}</A>.152</>153}154/>155);156}157158function explainChannel(channel: Channel): JSX.Element | string {159switch (channel) {160case "feature":161return "Updates, modified features, general news, etc. The default category for all news.";162case "announcement":163return "Use this rarely, only once or twice a month.";164case "about":165return "This is the meta-level category.";166case "event":167return (168"Let users know about upcoming company/conference events. These events are ONLY" +169" shown in the About page and are filtered from normal news views."170);171default:172return CHANNELS_DESCRIPTIONS[channel];173}174}175176function updateChannelParam(channel: string) {177const { query } = router;178179router.replace(180{181query: {182...query,183channel,184},185},186undefined,187{ shallow: true, scroll: false },188);189}190191function edit() {192return (193<>194<Title level={2}>195{isNew ? "Create New News" : `Edit News #${id}`}196</Title>197<Form198form={form}199initialValues={data}200labelCol={{ span: 4 }}201wrapperCol={{ span: 20 }}202onValuesChange={(_, allValues) => {203setSaved(null);204setData(allValues);205}}206onFieldsChange={() =>207setInvalid(form.getFieldsError().some((e) => e.errors.length > 0))208}209>210<Form.Item211label="Title"212name="title"213rules={[{ required: true, min: 1 }]}214>215<Input />216</Form.Item>217<Form.Item218label="Date"219name="date"220rules={[{ required: true }]}221extra={`Future dates will not be shown until it is time. This date is in the ${222form.getFieldValue("date")?.isAfter(dayjs()) ? "future" : "past"223}.`}224>225<DatePicker changeOnBlur showTime={true} allowClear={false} />226</Form.Item>227<Form.Item228label="Channel"229name="channel"230rules={[{ required: true }]}231extra={explainChannel(data.channel)}232>233<Select onSelect={(value) => updateChannelParam(value)}>234{CHANNELS.map((ch) => {235return (236<Select.Option value={ch} key={ch}>237{capitalize(ch)} ({CHANNELS_DESCRIPTIONS[ch]})238</Select.Option>239);240})}241</Select>242</Form.Item>243<Form.Item244label="Tags"245name="tags"246rules={[{ required: false }]}247extra={`Common ones are "jupyter", "latex" or "sagemath". Don't set too many, one is usually good enough.`}248>249<Select mode="tags" style={{ width: "100%" }} />250</Form.Item>251<Form.Item252label="Message"253name="text"254extra={`Markdown is supported. Insert images via ![](url), e.g. shared on ${siteName} itself.`}255rules={[{ required: true, min: 1 }]}256>257<Input.TextArea258rows={10}259style={{ fontFamily: "monospace", fontSize: "90%" }}260/>261</Form.Item>262<Form.Item263label="URL"264name="url"265rules={[{ required: false, type: "url" }]}266extra={`optional, external URL, will be shown as "Read more" link.`}267>268<Input allowClear />269</Form.Item>270<Form.Item label="Hide" name="hide" valuePropName="checked">271<Checkbox>If checked, will not be shown publicly.</Checkbox>272</Form.Item>273</Form>274<Divider />275<Row gutter={30}>276<Col span={16}>277<Paragraph>278<News news={{ ...data, id, date: data.date.unix() }} />279</Paragraph>280</Col>281<Col span={8}>282<Space direction="horizontal" size="large">283<Button284onClick={save}285disabled={saving || saved != null || invalid}286type="primary"287>288{isNew ? "Create" : "Save"}289</Button>290<Button href={slugURL({ ...data, id })}>Cancel</Button>291</Space>292<Divider type="horizontal" />293{error && <Alert type="error" message={error} />}294{saving && <Loading />}295{renderSaved()}296</Col>297</Row>298</>299);300}301302function content() {303if (profile == null) return <Loading />;304if (!isAdmin) {305return <Alert type="error" message="Not authorized" />;306}307return edit();308}309310const title = `${siteName} / Edit News / ${isNew ? "new" : `${id}`}`;311312const items = [313{ key: "/", title: <A href="/">{siteName}</A> },314{ key: "/news", title: <A href="/news">News</A> },315{ key: "new", title: isNew ? "Create New" : `Edit #${id}` },316];317318return (319<Customize value={customize}>320<Head title={title} />321<Layout>322<Header />323<Layout.Content324style={{325backgroundColor: "white",326}}327>328<div329style={{330minHeight: "75vh",331maxWidth: MAX_WIDTH,332padding: "30px 15px",333margin: "0 auto",334}}335>336<Breadcrumb style={{ margin: "30px 0" }} items={items} />337{content()}338</div>339<Footer />340</Layout.Content>341</Layout>342</Customize>343);344}345346export async function getServerSideProps(context: GetServerSidePropsContext) {347const { query } = context;348const { id: idQ } = query;349350if (idQ === "new") {351return await withCustomize({ context, props: { news: null } });352}353354const id = extractID(idQ);355if (id != null) {356try {357// false: bypasses cache358const news = await getNewsItem(id, false);359if (news != null) {360return await withCustomize({ context, props: { news } });361}362} catch (err) {363console.log("Error loading news item", err.message);364}365}366367return NOT_FOUND;368}369370371